这是「从零搭建 Agent」系列的第 2.5 章。在完成了最小 Agent Loop 的构建之后,我们已经打通了” 感知 - 思考 - 行动”(Observe-Think-Act)的基础循环机制。
但第 2 章的系统还太简单了:只有一个 Provider,只有 `calculator` 和 `get_weather` 两个教学工具,几乎不会制造真正的上下文压力。为了让后续的 Context Manager、Memory、Planner 这些 Harness 模块有东西可管,我们需要先做一次工程上的”基础堆量”:横向扩展 LLM Provider,纵向扩展基础工具箱,让 Agent 开始接触文件、命令、网页和更复杂的错误信息。
# 为什么需要” 堆量”?
在第 1 章里,我们把 Harness 定义为上下文工程(Context Engineering)和注意力管理(Attention Management)系统。它要解决的核心矛盾是 Agent 执行过程中产生的无限信息空间,与 LLM 有限且易受干扰的上下文窗口之间的矛盾。
但在第 2 章的最小 Agent Loop 里,这个矛盾其实还没有充分暴露:
当工具里只有 calculator 和 get_weather 时,工具结果通常只有几行文本;当 Provider 只有 OpenAI Responses API 时,Agent Loop 也不用面对不同模型接口之间的消息格式差异。整个系统虽然能跑,但还没有真正进入复杂环境,没有做出行动的能力。
所以第 2.5 章要做的不是直接上 Context Manager,而是先把 Agent 的手脚接上:
| 扩展方向 | 本章新增 | 为什么现在做 |
|---|---|---|
| 多 Provider | OpenAI Responses、OpenAI Chat Completions / compatible、Anthropic Messages | 验证 Agent Loop 不应该绑定某一家 API |
| 文件工具 | read_file 、 write_file 、 append_file 、 list_directory | 让 Agent 能读取和写入真实任务材料 |
| Shell 工具 | execute_command | 让 Agent 能运行本地命令,并把失败也作为 observation |
| Web 工具 | web_search 、 fetch_url | 让 Agent 能搜索实时信息并抓取网页正文 |
| 截断机制 | head / tail truncation | 防止工具输出一次性塞爆上下文 |
换句话说,上一章让 Agent 动起来,这一章能让 Agent 接触真实世界。
当前对应的 git 提交是:
66ce14c stage1.5 add core tools and more provider |
# 同类 Agent 是怎么实现多 Provider 支持和基础工具的
在写 stage1.5 之前,我依然以 Codex 和 Pi 作为工程上的指导。它们的复杂度比我们当前项目高很多,但各自提供了很清楚的工程参照。
# Codex:Provider 是配置项,工具是受治理的 runtime
Codex 把 Provider 抽象成一个 ModelProviderInfo ,里面不只是 base_url 和 env_key ,还包括:
wire_api | |
request_max_retries | |
stream_max_retries | |
stream_idle_timeout_ms | |
http_headers | |
env_http_headers | |
auth | |
aws | |
supports_websockets |
在 Codex 中,Provider 层要能独立承载鉴权、重试、超时、headers、wire format,不加重 Agent Loop 的负担。
Codex 的工具系统实现更重,它的工具 runtime 不只是 name 和 execute,还具有统一的 runtime contract、调用前后的 Hook 点、权限审批流程,以及明确的工具调用事件。对我们这一章最重要的启发有三点:
- ** 工具错误不能直接炸掉 Loop。** 任何调用失败都应该变成模型可见的 observation,让模型下一轮继续反思修正。
- 工具调用要能被权限管控。 Codex 有审批和沙箱实现工具安全可控调用;这一章里还没实现完整调用审批,已经留了扩展的点。
- **Provider 和 Tool 都不能写死在 Loop 里。**Loop 只负责循环语义,具体 API 和具体工具 runtime 都应该在边界层处理。
# Pi:TypeScript 里的轻量 Provider Registry 和 Core Tools
Pi 的 Provider 用 registry 注册不同 Provider,再根据 model.api 找到对应实现。
简化后大概是:
const apiProviderRegistry = new Map<string, ApiProvider>(); | |
export function registerApiProvider(provider: ApiProvider): void { | |
apiProviderRegistry.set(provider.api, provider); | |
} | |
export function getApiProvider(api: Api): ApiProvider | undefined { | |
return apiProviderRegistry.get(api); | |
} |
Pi 的 read 工具有几个关键点:
- 支持
offset/limit。 - 默认对文本做 line /byte 截断。
- 描述里会提醒模型:大文件要继续用 offset 读。
- 通过
ReadOperations抽象读取逻辑,未来可以委托到远端系统。
Pi 的 bash 工具也不是简单 child_process.spawn 一跑了事:
- 支持 timeout。
- stdout/stderr 会流式累积。
- abort 时会 kill process tree。
- 工具输出会做 truncation。
- 通过
BashOperations抽象执行逻辑,便于被远端执行或扩展 hook 替换。
# 核心模块一:扩展 LLM Provider
第 2 章里我们已经把模型调用抽象成了 LlmClient :
export interface LlmClient { | |
complete(request: LlmRequest): Promise<AssistantMessage>; | |
} | |
export interface StreamingLlmClient extends LlmClient { | |
stream(request: LlmRequest): AsyncIterable<LlmStreamEvent>; | |
} |
这个接口现在开始发挥价值。stage1.5 新增 Provider 时,没有改 Agent Loop 的核心语义:Agent 仍然只是把 messages 、 tools 、 systemPrompt 交给 LLM Client,然后等待一个统一格式的 AssistantMessage 。
OpenAI Responses、Chat Completions、Anthropic Messages 对工具调用的表示方式都不一样。但 Agent Loop 不应该知道这些差异。它应该只面对统一的内部消息格式、工具调用格式和流式事件格式。
当前支持三个 Provider:
| Provider ID | 对应接口 | 典型用途 |
|---|---|---|
openai-responses | OpenAI Responses API | 默认路径,延续第 2 章实现 |
openai-chat | OpenAI Chat Completions / compatible | OpenAI 兼容服务、本地 Ollama、第三方模型网关 |
anthropic | Anthropic Messages API | Claude 系列模型 |
我们采用了类似 Pi 的 Provider Registry 实现:
export class LlmProviderRegistry { | |
private readonly providers = new Map<string, LlmProviderFactory>(); | |
register(provider: LlmProviderFactory): void { | |
if (this.providers.has(provider.id)) { | |
throw new Error(`LLM provider already registered: ${provider.id}`); | |
} | |
this.providers.set(provider.id, provider); | |
} | |
get(id: string): LlmProviderFactory { | |
const provider = this.providers.get(id); | |
if (!provider) { | |
throw new Error(`No LLM provider registered for: ${id}`); | |
} | |
return provider; | |
} | |
} |
基本上能实现通过 provider id 找到对应 LLM Client factory 的任务。
# 核心模块二:丰富基础工具箱
第 2 章的工具系统已经支持注册、schema 暴露、参数校验、并行 / 串行执行、错误回填。stage1.5 在它上面新增了一组更接近真实 Agent 的 core tools。
Core tools 由 createCoreTools() 创建:
export type CoreToolset = "basic" | "files" | "shell" | "web" | "all"; | |
export function createCoreTools(options: CoreToolsOptions = {}): AgentTool[] { | |
const rootDir = options.rootDir ?? process.cwd(); | |
const toolset = options.toolset ?? "basic"; | |
const tools: AgentTool[] = [calculatorTool, mockWeatherTool]; | |
if (toolset === "files" || toolset === "all") { | |
tools.push(...createFileSystemTools({ rootDir, ...options.fileSystem })); | |
} | |
if (toolset === "shell" || toolset === "all") { | |
tools.push(createShellTool({ rootDir, ...options.shell })); | |
} | |
if (toolset === "web" || toolset === "all") { | |
tools.push(...createWebTools(options.web)); | |
} | |
return tools; | |
} |
可以通过命令行环境变量显示选择暴露给 Agent 的工具。文件、命令和网络都会改变 Agent 的能力边界,所以需要在运行入口显式选择。
# 文件系统工具
文件工具是 Agent 获得持久化能力和长文本感知能力的基础。
| 工具名称 | 核心功能 | 关键设计点 |
|---|---|---|
read_file | 读取 UTF-8 文件内容 | 支持 offset / limit ,对超长输出做 head 截断,并提示如何继续读取 |
write_file | 写入并覆盖文件 | 自动创建父目录,返回写入字节数 |
append_file | 追加文件内容 | 自动创建父目录和文件,适合渐进式生成 |
list_directory | 列出目录内容 | 返回结构化 JSON,包含名称、类型、大小、是否达到 entry limit |
文件工具最重要的保护是路径边界。 src/tools/helpers.ts 里的 resolveWithinRoot() 会确保路径不能逃出 rootDir :
const root = resolve(rootDir); | |
const target = resolve(root, path || "."); | |
const rel = relative(root, target); | |
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { | |
return target; | |
} | |
throw new Error(`Path escapes rootDir:${path}`); |
这意味着即使模型尝试路径穿越读取外部文件:
{ "path": "../../.ssh/id_rsa" } |
工具也会直接返回错误,而不是越过工作目录边界。(这个实现只是简单的防护)
read_file 的 offset 和 limit 是为后续 Context Manager 提前留下的接口:
{ | |
"path": "logs.txt", | |
"offset": 200, | |
"limit": 80 | |
} |
大文件不应该一次性塞进上下文。更合理的方式是分段读取、摘要、再决定要不要继续读。
# 系统执行工具
execute_command 是本章里最强也最危险的工具。
它赋予 Agent 执行 shell 命令的能力,
这个工具重点处理四件事:
| 机制 | 作用 |
|---|---|
timeoutMs | 防止命令无限阻塞 Agent Loop,默认 30 秒 |
| stdout /stderr 捕获 | 让模型看到命令正常输出和错误输出 |
| exit code | 非零退出码标记为 isError: true |
| tail truncation | 命令失败时保留末尾更有价值的错误信息 |
命令调用非零退出码、超时、abort 都会标记为 isError: true :
return { | |
content: content.content, | |
isError: result.aborted || result.timedOut || result.exitCode !== 0, | |
details: { | |
command, | |
workdir, | |
...result, | |
truncation: content.details | |
} | |
}; |
结果会被包装成这样的 observation:
Exit code: 3 | |
Timed out: false | |
Aborted: false | |
Wall time: 0.1 seconds | |
[stdout] | |
(empty) | |
[stderr] | |
bad |
这里延续了第 2 章的原则:失败也是一等公民(Failures as First-Class Citizens)。
工具失败时,Agent Loop 不应该直接崩掉。模型需要看到失败原因,然后在下一轮决定要不要改命令、读文件、换路径,或者向用户报告。
# 网络与搜索工具
网络工具被拆成两个小工具,用于模型没有原生网络访问能力的情况:
| 工具名称 | 核心功能 | 关键设计点 |
|---|---|---|
web_search | 通过 Tavily 搜索实时信息 | 返回标题、URL、摘要、分数;缺少 TAVILY_API_KEY 时返回模型可见错误 |
fetch_url | 获取 HTTP (S) URL 文本内容 | HTML 用 cheerio 去掉脚本和样式,只返回正文,并做 head 截断 |
web_search 负责帮模型找到候选信息源; fetch_url 负责读取某个具体页面。搜索结果和网页正文的粒度不同,如果混成一个工具,输出会很容易不可控。
# 截断:第一个迷你 Context Manager
这是一个很小的上下文保护层,主要学习了 Pi 中读取文件时保护上下文容量的机制。
默认限制是:
export const DEFAULT_MAX_LINES = 2000; | |
export const DEFAULT_MAX_BYTES = 50 * 1024; |
它提供两种方向:
| 截断方式 | 用在哪里 | 为什么 |
|---|---|---|
truncateHead | 文件读取、目录列表、网页正文 | 先看开头,适合正文和结构化列表 |
truncateTail | shell 命令输出 | 错误日志通常越靠后越有价值 |
截断时会给模型继续行动的提示,告诉模型下一步可以怎么继续行动:
[Truncated: showing lines 2-4 of 5; 13B/23B selected bytes. Use offset=4 to continue.] |
# 组合不同” 大脑” 和工具集
# 创建 Agent:Provider 和 Toolset 都来自环境
import { Agent, createCoreTools, createLlmClientFromEnv, type CoreToolset } from "../src/index.js"; | |
const llm = createLlmClientFromEnv(); | |
const toolset = (process.env.AGENT_TOOLSET ?? "basic") as CoreToolset; | |
const agent = new Agent({ | |
llm: llm.llm, | |
model: llm.model, | |
systemPrompt: "You are a concise assistant. Use tools when useful, then answer the user.", | |
tools: createCoreTools({ rootDir: process.cwd(), toolset }) | |
}); |
Agent 启动时,会打印当前模型调用的参数
[provider] | |
[toolset] | |
[maxTurns] |
如果要构造一个长链路任务,可以打开全部工具:
LLM_API_KEY=your_key_here \ | |
TAVILY_API_KEY=your_tavily_key_here \ | |
AGENT_TOOLSET=all \ | |
npm run demo -- "Read README.md, search for TypeScript tool-call design notes, fetch one result, and write a short summary to tmp/provider-tool-notes.md." |
这类任务会让 Agent 连续经历:
read_file | |
-> web_search | |
-> fetch_url | |
-> write_file | |
-> final answer |
也就是从” 能调用一个工具” 变成” 能完成一条多工具任务链”。在这样的长任务中,上下文压力会逐渐扩大,文件内容、搜索结果、网页正文、写入确认、可能的失败信息,都会进入历史。也就是说,下一章 Context Manager 的必要性得到了体现。
# 总结与展望
完成本章之后,Agent 的” 手” 更长了,“脑” 也更灵活了。
它不再只依赖 OpenAI Responses API,可以切换到 OpenAI-compatible 或 Anthropic;它也不再只会调用玩具工具,可以读取文件、写文件、执行命令、搜索网页、抓取正文。
但这同时带来一个新的问题:一个复杂任务的执行,会产生远比以前更多的上下文信息。
文件内容可能很长,命令输出可能很乱,网页正文可能夹杂噪声,工具失败可能连续发生。模型每一轮到底应该看到什么?哪些信息应该保留?哪些应该截断?哪些应该摘要?哪些状态应该固定在 prompt 前面?
这些问题,就是第 3 章上下文管理器(Context Manager)的起点。
从这里开始,Harness 不再只是理论里的” 挽具”,而是一个必须上场的工程模块。